Skip to content

fix(handler): generate a tool's outputSchema for the serialize contract - #1378

Merged
connor4312 merged 2 commits into
modelcontextprotocol:mainfrom
atarutin:output-schema-for-serialize
Nov 2, 2026
Merged

connor4312 merged 2 commits into
modelcontextprotocol:mainfrom
atarutin:output-schema-for-serialize

Conversation

@atarutin

Copy link
Copy Markdown
Contributor

Closes #1374.

schema_for_output now generates with SchemaSettings::draft2020_12().for_serialize(). Tool results are values the server serialises, so their schema describes what Serialize writes: an Option without skip_serializing_if is required, and a type that accepts several forms advertises only the one it writes. Inputs (schema_for_type) keep the deserialize contract. There is no public API change, and for types whose two forms are equal the output schema is unchanged.

Tests:

  • an always-written Option is required in an output schema, and a skipped one is not;
  • an input Option stays optional, and serde does accept the missing key.

Ran: cargo test -p rmcp --all-features, CI's rmcp clippy command (all features except local, -D warnings), and cargo +nightly fmt --check.

schema_for_output used schemars' default deserialize contract, so an
always-written Option or #[serde(default)] field was advertised as optional
and a type accepting several forms advertised all of them. Tool results are
serialised; inputs keep the deserialize contract.

Closes modelcontextprotocol#1374
@atarutin
atarutin requested a review from a team as a code owner October 17, 2026 18:07
@github-actions github-actions Bot added T-core Core library changes T-handler Handler implementation changes labels Oct 17, 2026
@github-actions
github-actions Bot requested a review from connor4312 October 17, 2026 18:07
@github-actions

Copy link
Copy Markdown
Contributor

@connor4312 is on triage rotation this week and is the reviewer for this one.

Labels come from the PR labeler. Please review the change.

Automated triage. Labels are a first pass and the assigned maintainer has the final say.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atarutin this is mergeable at b1b6189. It fixes the example in #1374, and reverting the one line in schema_for_output fails the new test.

non-blocking: this changes what existing servers advertise. required grows on upgrade for a plain Option field and a #[serde(default)] field. A client that generates types from outputSchema sees those fields flip from optional to required. That is the intended correction, and the PR title carries it into the changelog.

non-blocking: #[tool(output_schema = schema_for_type::<T>())], as in test_json_schema_detection.rs:59, stays on the deserialize contract. That stays untouched, since an explicit override is the author saying what they mean.

$ cargo test -p rmcp --features client,server,macros,elicitation --lib --test test_json_schema_detection --test test_list_tools_result --test test_structured_output --test test_tool_builder_methods   # head b1b6189, rustc 1.96.1
241 passed; 7 passed; 1 passed; 21 passed; 6 passed
# schema_for_output calling schema_for_type again, new tests kept
schema_for_output_describes_the_serialized_form ... FAILED

# struct { name: String, note: Option<String>, #[serde(default)] tags: Vec<String> }, schema_for_output::<T>()
# base 50fba5c   "required":["name"]
# head b1b6189   "required":["name","note","tags"]    # "tags" still carries "default":[]
$ cargo fmt --check   # clean

# not run: clippy, the full --all-features suite (no pkg-config for openssl-sys here)

@connor4312, you're the assigned reviewer. The changelog line covers the required change. Does it also need a release note?

@connor4312

Copy link
Copy Markdown

Thanks for the fix! The change looks right, but I'd like a few more tests before merging:

  1. Same type, both paths. Use one struct that derives both Serialize and Deserialize, call schema_for_type::<T>() and schema_for_output::<T>() in the same test, and check that they give different required lists. The two now have separate caches, so this test would catch them getting mixed up later.
  2. #[serde(default)]. The commit message says this case is fixed, but no test covers it. Add a field like #[serde(default)] tags: Vec<String> and check that it shows up in required for the output schema.
  3. The input test. schema_for_input_keeps_the_deserialize_form calls schema_for_type, not schema_for_input, and it already passes on main, so it doesn't check anything this PR changes. Please call schema_for_input instead. The serde_json::from_value part only tests serde, so it can be dropped.

Optional: in the first test, compare properties and required together with a single assert_eq!, so the test also fails if sometimes disappears from the schema.

@SIDDARTHAREDDY8 SIDDARTHAREDDY8 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the diff at this head (code-read only, no local run). The direction looks right: tool outputs are serialized by the server, so outputSchema should describe the serialize form, and splitting the serialize cache (CACHE_FOR_SERIALIZED) from the deserialize cache means a type requested both ways cannot get the wrong schema from the other cache. I also confirmed the #[tool] macro path calls schema_for_output (rmcp-macros/src/tool.rs), so generated tools pick this up.

One gap between the docs and the tests: the doc comment on schema_for_serialized promises two more serialize-contract behaviors, a Decimal serializing as a string and a #[serde(default)] field always being written, but the new tests only cover Option with and without skip_serializing_if. Either add cases for those two, or narrow the comment to what the tests cover.

One scoping question: this fixes attribute-driven divergence between the two forms. A type with a hand-written Serialize impl that writes a different shape than its JsonSchema derive describes would still mismatch, since schemars works from the derive. Is that intentionally out of scope here, or worth a note in the function docs?

Call schema_for_type and schema_for_output on one type that derives both
Serialize and Deserialize, so their separate caches cannot be mixed up;
check that a #[serde(default)] field is required in the output schema and
not the input; make the input test call schema_for_input; compare the
output test's properties and required together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@atarutin

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Added in e7830c6:

  1. schema_for_type_and_output_of_one_type_differ: one type deriving both Serialize and Deserialize, input then output, checking that required differs (["value"] vs ["value", "maybe", "tags"]).
  2. schema_for_output_requires_a_serde_default_field: a #[serde(default)] tags: Vec<String> is required in the output schema and not in the input schema.
  3. schema_for_input_keeps_the_deserialize_form now calls schema_for_input, and the from_value part is gone.

I also took the optional one: the first test compares properties and required in one assert_eq!. With .for_serialize() removed, all three output-side tests fail.

@connor4312 connor4312 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

@connor4312
connor4312 merged commit df2af23 into modelcontextprotocol:main Nov 2, 2026
24 checks passed
@github-actions github-actions Bot mentioned this pull request Nov 1, 2026
glslang added a commit to glslang/windbg-mcp that referenced this pull request Nov 9, 2026
rmcp 3.9.0 (modelcontextprotocol/rust-sdk#1378) generates a tool's
outputSchema for what `Serialize` writes rather than what `Deserialize`
accepts, so an `Option` or `#[serde(default)]` field without
`skip_serializing_if` is now `required`. Eleven `$defs` and the two
Secure Kernel trace roots gain required fields: 450 B across sixteen
tools' output schemas, nothing model-visible (139,647 B unchanged, wire
416,155 -> 416,605 B against the 420,000 B ceiling).

Diffing all 89 tools' output schemas built from this tree against rmcp
3.8.0 and 3.9.0 shows only `required` lists move; no property, type or
branch changes. Every newly required field is a derived, always-written
key, and structuredContent is `serde_json::to_value` unmodified, so a
result validates against the new schema.

Re-records tests/golden/tools_list.json and tool_budget.json, moves the
standalone M6 wire pin to 15,893 B, and carries the new total into
docs/token-budget.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-handler Handler implementation changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

outputSchema is generated for the deserialize contract; tool results are serialised

4 participants